課程:Redux 思維與 RTK 核心 第 5 堂:多個 Slice 組合管理
22Selector 設計模式
在上一節中,我們學會了如何將多個 Slice 合併到單一的 Store 中,並透過 state.counter 或 state.todos 這種命名空間來存取資料。你可能會想:「既然我已經可以在元件中透過 state => state.counter.value 拿到資料了,為什麼還需要特別學習 Selector 的設計模式呢?」
想像一下,如果你的應用程式有 50 個元件都在使用 state.counter.value。某天你發現這個狀態結構不夠用了,決定將 value 改名為 count。這時,你必須手動修改這 50 個元件。這種「直接存取內部結構」的做法,會讓 UI 層與狀態層過度耦合,這正是大型專案維護噩夢的開始。
這一節我們將探討如何透過設計良好的 Selector 函數,為你的 Redux 狀態建立一層「抽象介面」,讓你的程式碼不僅更具語意化,還能大幅提升維護性與效能。
什麼是 Selector?
簡單來說,Selector(選擇器) 就是一個純函數,它接收整個 Redux 的 state 作為參數,並從中「選擇」出元件所需要的特定片段或計算後的結果。
// 一個最基本的 Selector 函數
const selectCount = (state: RootState) => state.counter.value;
在 React 元件中,我們透過 useAppSelector 鉤子來執行這個函數。這看起來只是一個小小的間接層,但它帶來的架構優勢卻是巨大的。
Colocate(同地協作)模式
在 Redux 的最佳實踐中,我們極力推薦 Colocate Pattern(同地協作模式)。這意味著:你應該將 Selector 函數定義在與該狀態相關的 Slice 檔案中,而不是定義在 React 元件內。
為什麼要這麼做?
1. 封裝內部資料結構
對於 React 元件來說,它不需要(也不應該)知道 counter 狀態在 Store 裡面的具體路徑。它只需要知道「我要取得計數值」。
如果我們將 selectCount 定義在 counterSlice.ts 中,當未來 value 欄位名稱改變,或層級結構發生變動時,我們只需要修改 Slice 檔案中的那一行 Selector 程式碼,所有引用該狀態的元件都能無痛更新。
2. 提高邏輯複用性
很多時候,不同的元件會需要相同的「衍生資料」(Derived Data)。例如,計數器是否達到上限、待辦清單中未完成的數量等。如果將這些邏輯寫在元件裡,你會發現自己在不斷地複製貼上;如果寫在 Selector 中,任何元件都能直接導入使用。
3. 語意化命名慣例
在命名 Selector 時,社群普遍遵循 selectXxx 的慣例。例如:
selectCountselectIsLoadingselectTodoById
這種命名方式讓你在 React 元件中使用時,程式碼讀起來像是一句自然的英文:const count = useAppSelector(selectCount);(常數 count 等於使用 App Selector 選擇 count)。這極大地提升了程式碼的可讀性。
TypeScript 與 Selector 的型別定義
在實作 Selector 時,精確的型別標註是確保開發體驗的關鍵。由於 Selector 接收的是全域狀態,我們必須正確引入 RootState。
讓我們看一個標準的 counterSlice.ts 範例:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '../app/store'; // 引入從 store 推導出的 RootState
interface CounterState {
value: number;
status: 'idle' | 'loading' | 'failed';
}
const initialState: CounterState = {
value: 0,
status: 'idle',
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
// ... 其他 reducers
},
});
export const { increment } = counterSlice.actions;
// --- Selectors 定義區 ---
// 基礎 Selector:直接回傳 state 片段
export const selectCount = (state: RootState) => state.counter.value;
export const selectStatus = (state: RootState) => state.counter.status;
// 衍生 Selector:根據 state 計算出新結果
export const selectIsEven = (state: RootState) => state.counter.value % 2 === 0;
export default counterSlice.reducer;
在元件中使用時,變得非常簡潔:
import { useAppSelector, useAppDispatch } from '../app/hooks';
import { selectCount, selectIsEven, increment } from './counterSlice';
export const Counter = () => {
const count = useAppSelector(selectCount);
const isEven = useAppSelector(selectIsEven);
const dispatch = useAppDispatch();
return (
<div>
<p>當前數值: {count}</p>
<p>是否為偶數: {isEven ? '是' : '否'}</p>
<button onClick={() => dispatch(increment())}>增加</button>
</div>
);
};
效能警訊:為什麼需要關注 Re-render?
在使用 Selector 時,有一個非常容易踩到的效能陷阱。要理解這個問題,我們必須回顧 useSelector 的運作機制。
**useSelector**** 會在每次 Action 被 dispatch 後執行 Selector 函數,並將回傳的結果與「上一次的結果」進行「嚴格相等比較 (===)」。如果結果不同,元件就會重新渲染。**
這在處理基本型別(如 number, string, boolean)時運作良好,但在處理物件或陣列時,問題就來了。
錯誤示範:在 Selector 中直接回傳新物件
假設你在 Selector 中做了資料轉換:
// ❌ 效能危險的寫法
export const selectFormattedTodos = (state: RootState) => {
return state.todos.items.map(todo => ({
...todo,
title: todo.title.toUpperCase()
}));
};
在這個例子中,即使 state.todos.items 的內容完全沒有變化,但因為 .map() 每次執行都會回傳一個 全新的陣列引用,useSelector 會判定「資料已變更」,進而導致元件在每一次任何 action 發生時都強制重新渲染。這在列表很大或頻繁更新的應用中,會造成明顯的卡頓。
進階武器:Memoized Selector (Reselect)
為了解決上述效能問題,我們需要 Memoization(記憶化)。這是一種快取技術:如果輸入參數沒有變,就直接回傳上一次計算好的結果(保持相同的引用)。
Redux Toolkit 內建集成了 reselect 庫中的 createSelector 工具。
createSelector 的運作原理
createSelector 接收兩個部分:
- Input Selectors(輸入選擇器):一組基礎 Selector。
- Output Selector(輸出轉換器):接收輸入選擇器的結果,並進行邏輯運算。
只有當輸入選擇器的回傳值(經過 === 比較)發生變化時,輸出轉換器才會重新執行。
import { createSelector } from '@reduxjs/toolkit';
// 1. 基礎 Selector
const selectTodoItems = (state: RootState) => state.todos.items;
// 2. 使用 createSelector 建立 Memoized Selector
export const selectCompletedTodos = createSelector(
[selectTodoItems], // 相依的輸入
(items) => {
// 只有當 items 陣列的引用改變時,這裡才會重新執行
console.log('計算中...');
return items.filter(todo => todo.completed);
}
);
透過 createSelector,即使 action 觸發了 Store 的更新,只要 items 沒變,selectCompletedTodos 就會回傳跟上一次一模一樣的陣列引用。這確保了 useSelector 的比較會通過,從而避免不必要的 UI 更新。
何時該使用 createSelector?
你不必為每一個 Selector 都加上 createSelector。以下是判斷標準:
- 回傳基本型別? 不需要(直接寫簡單函數即可)。
- 只是單純回傳 State 中的現有物件? 不需要(引用沒變)。
- 涉及運算並回傳新物件/陣列?(例如
.filter,.map,.sort, 物件組合)強烈建議使用。
重點回顧與設計建議
在設計你的 Selector 層時,請記住以下原則:
- 越細越好:盡量讓 Selector 專注於選取最小必要的資料。如果一個元件只需要
user.name,就寫一個selectUserName而不是把整個user物件拿過去,這樣當user.age改變時,該元件就不會被影響。 - 邏輯集中化:複雜的資料轉換邏輯應該放在 Selector 中,而不是 React 元件的
useEffect或useMemo裡。這能讓你的 UI 元件保持乾淨,只專注於渲染。 - 型別一致性:務必讓 Selector 的參數型別為
RootState。雖然這在 Slice 檔案中會形成一種對store.ts的「反向引用」,但在實務上這是可以接受的,只要你使用import type就能避免循環依賴的執行期問題。
現在,你已經掌握了 Selector 的設計精髓。這不只是關於「如何拿資料」,更是關於「如何定義 UI 與資料之間的契約」。良好的 Selector 層會讓你的專案在規模擴大時,依然保持輕巧且易於重構。
從理論到實踐的橋樑
我們剛深入探討了 Selector 的設計模式,包括 Colocate 模式、命名慣例、TypeScript 整合,以及如何利用 Memoization 優化效能。這些知識將是你從「只會寫 Counter」晉升為「能處理複雜狀態」的關鍵轉折點。在接下來的單元中,我們將把這些模式應用到一個更具挑戰性的練習:從零開始建構一個完整的 todoSlice。你將親手實作具備過濾功能與複雜狀態轉換的待辦清單,並見證良好的 Selector 設計如何讓開發過程變得行雲流水。